Skip to content

Serve WebSocket upgrades from API routes - #228

Merged
JoviDeCroock merged 2 commits into
mainfrom
JoviDeCroock/websocket-upgrade-101-support
Jul 26, 2026
Merged

Serve WebSocket upgrades from API routes#228
JoviDeCroock merged 2 commits into
mainfrom
JoviDeCroock/websocket-upgrade-101-support

Conversation

@JoviDeCroock

Copy link
Copy Markdown
Owner

Summary

Pracht could host Durable Objects via workerExportsFrom but could not serve a WebSocket upgrade. Pracht owns the worker's fetch, and every API response was rebuilt through new Response(response.body, { status, headers }) to stamp the default security headers (packages/framework/src/runtime-headers.ts). A 101 handshake cannot survive that reconstruction: the Response constructor rejects any status below 200, and Cloudflare's webSocket handle is not part of ResponseInit, so it is dropped even where the status is tolerated.

Worth noting how this presented: the RangeError was thrown inside the API try block, so it was swallowed by renderApiErrorResponse and surfaced as an opaque 500 rather than a crash — which is why it never showed up as an obvious bug.

Framework

  • New isProtocolSwitchResponse() (exported from @pracht/core/server): true for status < 200 or any response carrying a webSocket handle. It reads webSocket explicitly rather than using in, because workerd defines a webSocket getter on Response.prototype.
  • withDefaultSecurityHeaders and withRouteResponseHeaders return protocol switches untouched. Skipping the headers costs nothing — a handshake has no body for a sniffing or framing policy to protect.

Cloudflare adapter

  • Upgrade requests skip the ISG and static-asset lookups. Previously every handshake forwarded an Upgrade request to the assets binding — a wasted subrequest per connection against a Fetcher that can never satisfy it.
  • preventHeuristicCaching already had a status === 101 guard; it now shares the framework helper.

Security change
api.requireSameOrigin (on by default) now also applies to upgrade requests. They are GET, so the method-based check exempted them — but browsers do not apply CORS to WebSocket, so without this any page on the web could open a cookie-authenticated socket (cross-site WebSocket hijacking). This cannot break existing apps, since no upgrade could reach a handler before this change. Handshake authentication remains the app's job (API middleware works normally).

Node / Vercel
Still unsupported, and on Node this is structural rather than an adapter gap: http.Server delivers upgrades to its upgrade event, not to the request handler, so a handshake never reaches pracht at all. Rather than add dead code, the docs show attaching a ws server to the same HTTP server alongside pracht's exported handler — including the reminder to check Origin yourself there, since pracht's guard cannot apply.

Example
examples/cloudflare gains a working ChatRoom Durable Object (hibernation API) and src/api/ws.ts route.

Testing

  • pnpm e2e — 80 passed
  • pnpm format
  • pnpm lint — see note below
  • pnpm test — 902 passed
  • pnpm typecheck

New coverage: packages/framework/test/websocket.test.ts (pass-through identity, isProtocolSwitchResponse edge cases incl. the null-webSocket getter), upgrade-origin cases in security.test.ts, adapter-level tests that the handshake never touches the assets binding, and build assertions for the new DO in e2e/cloudflare-build.test.ts.

The pass-through tests were verified to fail without the fix (5 of 14 red when the guard is removed), so they pin the actual bug rather than just the new code path.

Note: pnpm lint runs eslint, which is not installed in this repo — the command fails before linting anything. Ran pnpm exec oxlint . instead (0 warnings, 0 errors), which is what the script appears to have been intended to invoke. Flagging as a pre-existing issue, unrelated to this PR.

Checklist

  • Docs updated if needed — docs/ADAPTERS.md (Cloudflare + Node WebSocket sections), docs/REQUEST_FLOWS.md (upgrade flow diagram), and the published docs site (api-routes.md, adapters.md)
  • Skills updated if needed — audit-csrf (upgrades are a mutation-equivalent surface; per-message authorization), audit-headers (protocol switches are a deliberate exception, not a finding); both bumped to 1.2.0
  • Changeset added if published packages changed — .changeset/websocket-upgrades.md (minor: @pracht/core, @pracht/adapter-cloudflare)

🤖 Generated with Claude Code

Pracht owns the worker's fetch and rebuilt every API response through
`new Response(response.body, { status, headers })` to stamp the default
security headers. A 101 handshake cannot survive that: the Response
constructor rejects statuses below 200, and Cloudflare's `webSocket`
handle is not part of ResponseInit, so it would be dropped even where the
status was tolerated. The RangeError was caught by the API error path, so
a WebSocket handler returned an opaque 500.

Protocol-switch responses now leave the pipeline as the same object the
handler produced — socket intact, no header or cache post-processing. On
Cloudflare, upgrades also skip the ISG and asset lookups, so a handshake
no longer costs a wasted subrequest against the assets binding.

Security: `api.requireSameOrigin` now covers upgrade requests, which are
GET and were previously exempt from the method-based check. Browsers do
not apply CORS to WebSocket, so without it any page could open a
cookie-authenticated socket (cross-site WebSocket hijacking). No app can
break — no upgrade could reach a handler before this.

Node and Vercel still cannot serve upgrades. On Node that is structural:
http.Server routes upgrades to its `upgrade` event, never to the request
handler. Documented the ws-alongside-pracht pattern instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JoviDeCroock
JoviDeCroock marked this pull request as ready for review July 26, 2026 05:13
@JoviDeCroock
JoviDeCroock merged commit 9f70b17 into main Jul 26, 2026
3 checks passed
@JoviDeCroock
JoviDeCroock deleted the JoviDeCroock/websocket-upgrade-101-support branch July 26, 2026 05:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant